Repository navigation
Evict a full store down to a low-water mark, and keep draining across restarts - #14
Merged
Merged
Conversation
RhysSullivan
marked this pull request as ready for review
November 2, 2026 21:21
This was referenced Nov 2, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Once the workerd collector's store reaches its size bound, every export pays for an eviction pass, forever. Executor self-host bundles this collector with a 1 GiB bound. Users report an idle container at about 13% of a core, with 130–200 GiB a day of writes, once the store is full (UsefulSoftwareCo/executor#2194).
Cause
MotelCollector.writerunsholdSizeBoundafter every export. OnceusedBytesis over the bound, that runscleanupExpired.usedBytesbarely moves and the store stays at or just over the bound. The next export crosses it again.DELETE FROM span_operation_fts WHERE trace_id IN (…)reads the entire operation index, becausetrace_idis anUNINDEXEDFTS5 column. On top of that comeSUM(span_count)overtrace_summaries, the orphan scans, and repeated iterations within the 500 ms budget.The result is one full-store pass per export, which is about one a second on an idle Executor.
Fix
This builds on #11, rebased onto
mainand kept as its own commit with one fix on top.MOTEL_OTEL_EVICTION_LOW_WATER_PERCENT. After that, exports find room until the store fills again./api/ingestreportsevictions(passes, traces).motel_maintenance. At a 1 GiB bound, a drain takes several 500 ms passes. workerd can unload the collector between them; with an in-memory flag, the drain then stopped at about 1011 MiB instead of 922.The bound itself still holds after every export, so
holdSizeBoundstays on the ingest path.Supersedes #11 and #12. Both are based on
7cbec5a, which predates #13.Before / after
Reproduced with the built workerd collector, the bundled 1 GiB bound and product-shaped spans. The store held 264–269k spans; the reporter's store held 268k. After filling past the bound, an idle stream sent one trace a second. Numbers are the workerd process's
/procdeltas over a 90–120 s steady-state window, after any drain.The same stream on an almost empty store: 0.7% CPU, 1.8 MiB/s of writes at 17 spans/s; 0.3% CPU, 0.38 MiB/s of writes at 2 spans/s.
Not in the "after" rows: each drain from the bound to 90% evicts about 2,200 traces in about 8 passes. At 17 spans/s that wrote about 1.2 GiB over two minutes, and the store needs about 25 minutes to refill. Averaged over a refill, that adds about 0.8 MiB/s at 17 spans/s and about 0.1 MiB/s at 2 spans/s.
Writes fall less than CPU and reads. That is expected: in steady state the store must delete as much as it ingests, so those writes scale with the span rate, not with how evictions are batched.
The
before2 spans/s windows overlapped other test runs on the host, so its CPU and p95 figures are noisy.Not changed here (follow-ups)
Per-export write cost. Ingest itself writes about 300 KiB per export, even for a 2-span export on an empty store:
/var/tmp/etilqs_*file per export (transactionSyncinside workerd's implicit transaction).It also touches many random-key indexes.
idx_span_attributes_trace_spanduplicates the(trace_id, span_id, key)primary key's prefix, and is 148 MiB of a full 1 GiB store.Pass cost still O(store). The operation index delete during a drain, and the orphan log scans every alarm, still read the whole store. Fixing the first needs the FTS rowid tied to
spans.rowid, which means a one-off index rebuild.Broken FTS merge.
incrementalFtsMergesendsINSERT INTO t(t) VALUES('merge=100'), which SQLite rejects ("SQL logic error"), and the error is swallowed. The intended command isINSERT INTO t(t, rank) VALUES('merge', 100).Verified:
tsc --noEmit;bun test: 93 pass across the Bun-side suites, 7 pass acrossworkerd.boundsandworkerd.integration, including the test in the PR above.